如果不清楚应用程序的行为,就无法创建有效的基准测试,无论大小,都不能。要想弄清哪些基准测试与应用程序性能相关,就需要先对应用程序做全面的分析。
有不少工具有用来检测Java应用程序,有些通过在修改字节码来创建一个应用程序的特别版本来检测,有些则可以在不修改原有程序的基础上进行在线分析,JRockit Mission Control套件使用的是后者的方法。在第6章会对JRockit Mission Control做详细介绍。
对应用程序详细分析可以揭示出运行时把时间都花在哪些方法上了,垃圾回收工作是什么运行模式,以及哪些锁竞争激烈,哪些锁没什么竞争等信息。
其实,对应用程序进行分析并不一定非得要用什么神奇的工具,一切从简的话,直接用System.out.println
方法在控制台打印出相关信息即可。
当收集到了足够的信息后,可以开始将应用程序划分为具体的子程序以分别进行基准测试。在创建基准测试之前,还需要仔细确认一下选定的子程序和基准测试是否针对同一个性能问题。
在对应用程序正式进行基准测试之前,要注意先让应用程序 热身(warm-up),以使其达到稳定运行的状态。此外,思考以下问题:
服务器端应用程序通常有多个功能子模块,适合对每个特定领域做基准测试。
理想的基准测试是一个模拟运行应用程序某一部分的、自包含的小程序。如果某个目标应用程序不易安装,而且要处理的输入数据太多,因而不易编写基准测试程序,那么可以尝试继续细分该应用程序,将之分解为一些可以处理有限输入数据的 黑盒,然后对这些 黑盒做基准测试,以此为基准对整个应用程序做出判断。
除了一些非常小的基准测试和或验证某些概念的代码外,在做基准测试时,最好能够 "置身事外"。所谓 "置身事外"是指通过一些 外部驱动程序(external driver)来运行基准测试。在测试系统性能时,驱动程序独立于基准测试代码之外运行。
驱动程序通常会增加基准测试的工作量,例如可能会增加网络传输开销,因此如果基准测试要测量应用程序的响应时间,则得到的测试结果中会包括这部分通信时间。
使用驱动程序的好处是可以精确测量应用程序的响应时间,而受到数据生成或工作负载的影响。此外,为了确保数据生成或工作负载不会成为基准测试中的瓶颈,可以使驱动程序保持在较低的工作负载下运行。
下面的示例代码中,使用随机数据测试MD5算法,这个例子很好的说明了为什么执行基准测试时要 置身事外。
import java.util.Random;
import java.security.*;
public class Md5ThruPut {
static MessageDigest algorithm;
static Random r = new Random();
static int ops;
public static void main(String args[]) throws Exception {
algorithm = MessageDigest.getInstance("MD5");
algorithm.reset();
long t0 = System.currentTimeMillis();
test(100000);
long t1 = System.currentTimeMillis();
System.out.println((long)ops / (t1 - t0) + " ops/ms");
}
public static void test(int size) {
for (int i = 0; i < size; i++) {
byte b[] = new byte[1024];
r.nextBytes(b);
digest(b);
}
}
public static void digest(byte [] data) {
algorithm.update(data);
algorithm.digest();
ops++;
}
}
如果基准测试的目标是衡量MD5算法的性能,那么上面的测试示例就可算是个反面教材了,由于生产随机数据的时间也被统计在内,所以基准测试的结果反映的是MD5算法和随机数生成算法两者结合之后的性能。虽然这是可能无心的,但却使测试结果不再可靠。下面是更加合理的基准测试代码:
import java.util.Random;
import java.security.*;
public class Md5ThruPutBetter {
static MessageDigest algorithm;
static Random r = new Random();
static int ops;
static byte[][] input;
public static void main(String args[]) throws Exception {
algorithm = MessageDigest.getInstance("MD5");
algorithm.reset();
generateInput(100000);
long t0 = System.currentTimeMillis();
test();
long t1 = System.currentTimeMillis();
System.out.println((long)ops / (t1 - t0) + " ops/ms");
}
public static void generateInput(int size) {
input = new byte[size];
for (int i = 0; i < size; i++) {
input[i] = new byte[1024];
r.nextBytes(input[i]);
}
}
public static void test() {
for (int i = 0; i < input.length; i++) {
digest(input[i]);
}
}
public static void digest(byte [] data) {
algorithm.update(data);
algorithm.digest();
ops++;
}
}
在根据基准测试的结果作出结论之前,对应用程序的各个时间指标进行统计是非常重要的。最简便的方法是重复多次执行测试程序,以得到多次测量结果的标准差(standard deviation),只有当标准差在预定范围之内时,基准测试的结果才是真实有效的。
如果可能的话,应尽可能在多个同类机器上多次运行基准测试程序,这样有助于发现无心的配置错误,例如忘记了配置负载生成器,结果导致基准测试的结果较低等等。如果所有的基准测试都在同一台机器上执行,就难以发现这种因配置而产生的错误。
微基准测试只包含很少量的代码,只对整个应用程序的很少一部分功能进行测试,例如JVM创建多个java.math.BigInteger
实例的速度,或者JVM执行AES加密算法的速度。微基准测试易于编写,只需要包含目标功能或算法包括进来即可。
微基准测试易写好用,在验证大型应用程序的性能瓶颈时往往可以提供重要线索,因而成为优化已知问题代码和完善性能回归测试的中坚力量。
强烈建议应用程序的开发者在做回归测试时使用微基准测试,在修复bug时要进行单元测试,这样可以保证已解决过的问题不会再带来麻烦。
如果大型应用程序可以简化为多个微基准测试,或者是"小基准测试",事情会简单很多,遗憾的是,现在应用程序都太复杂,很难这样处理。不过,通过分析系统行为,在掌握基本原理的基础上,还是可以创建很多微基准测试的。例如,下面的示例代码被用来对JRockit JVM做性能回归测试:
public Result testArrayClear(Loop loop, boolean validate) {
long count = 0;
OpsPerMillis t = new OpsPerMillis("testArrayClear");
t.start();
loop.start();
while (!loop.done()) {
int[] intArray = new int[ARRAYSIZE];
System.arraycopy(this.sourceIntArray, 0, intArray, 0, ARRAYSIZE);
//Introduce side effects:
//This call prevents dead code elimination
//from removing the entire allocation.
escape(intArray);
count++;
}
t.end();
return new OpsPerMillis(count, t.elapsed());
}
Java编程需要要求在为对象分配内存后,要将内存清零,这样对象的成员变量会初始化为默认值。不过,就上面的代码来说,JRockit中的代码优化器应该可以检测出数组对象initArray
在创建之后会立即被其他数据完全填充,而且由于initArray
不是volatile
变量,因此在分配内存后没必要执行清零操作。如果因为某种原因而使这样优化执行失败,则基准测试的结果可以反映出代码运行时间变长,质量保证框架可以发出相应的警告。
在建立微基准测试之前,应该先弄清楚影响应用程序性能的关键因素都有哪些。例如,如果要测试XML解析器,就应该使用各个不同大小的XML文件来测试其执行性能;如果应用程序使用了java.math.BigDecimal
类,那么最好能写一些自包含的小程序操作BigDecimal
类来测试一下具体的性能。
如果微基准测试本身是无效的,或者不能针对目标问题生成有用的结果,这就不仅仅是浪费时间和精力了,因为如果这些数据被误认为是准确的,那所带来的危害可就大了。例如,在测试java.util.HashMap
类的性能时,光是创建HashMap
的实例并用数据进行填充是不够的,没法真实反映HashMap
类的性能。当HashMap
做扩容时,重新计算已有元素哈希需要多久?获取元素需要多久?不同元素的哈希值冲突时该如何处理?
类似的,在测试java.math.BigDecimal
类的实现时,只执行加法运算显然是不够的。如果除法运算有性能问题该咋办呢?
在建立微基准测试时,关键点是要理解被测试目标,要注意检查基准测试是否是有效的,以及测试结果是否是有效的。
上面的两个例子虽是有意为之,但仍说明无效的基准测试会使人误入歧途。更实际一点的例子是对某个类库中的同步方法进行基准测试。如果应用程序中对同步操作的竞争非常激烈,那么在做微基准测试时,显然不应该在单线程环境下进行,大量减少线程数是负载降低会从根本上改变锁的行为,这也是使基准测试失效主要原因之一。如果要对同步操作进行基准测试的话,就要确保其中所有的锁都处在被大量线程竞争的环境中。
最后,要尽量剔除与目标问题无关的代码,以防止影响最终的测试结果。如果单纯想测试算法实现的执行性能,那么创建大量对象,增加垃圾回收器的工作量就显得完全没必要了,选择垃圾回收策略时也要选择不会对算法执行产生不良影响的垃圾回收策略(例如很大的堆,没有新生代,以最大化吞吐量为优化目标等)。
在做基准测试时,另一个常见的错误就是以为所有的JVM都会做栈上替换(on-stack replacement,即热点方法在会在执行过程中被替换为优化编译后的代码)。在本书2.6.3.4节中曾经提到过,JRockit是不会做栈上替换的。因此,如果基准测试的主要工作了都集中在主函数的某个循环体内,那么即使某些方法已经被标记为热方法并已经经过优化编译了,但这些优化编译后的代码可能永远都不会被执行到。
下面的示例代码中,主函数的循环体内包含了一些复杂操作,但在像JRockit这种不使用栈上替换的JVM中,对主函数的优化编译永远不会起作用。对此的解决方法是将那些复杂操作移到单独的函数中,然后在循环体中调用该函数。
public class BadMicro {
public static void main(String args[]) {
long t0 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
// complex benchmarking operation
}
long t1 = System.currentTimeMillis();
System.out.println("Time: " + (t1 - t0) + " ms");
}
}
在2.6.2节中曾经提到过,JVM的启动时间的长短依赖于加载初始类和生成启动代码的时间。如果基准测试的目标只是测量运行时间,那么就要注意从总体运行时间中排除启动时间的干扰。此问题的解决方法之一就是让基准测试执行足够的操作以降低启动时间带来的影响。
一定要注意,微基准测试的执行时间通常都很短,而在这点时间里,JVM的启动时间还占去了不少份额。如果在微基准测试中执行100个浮点数的乘法操作,那么总体运行时间就几乎都被启动时间占去了;而如果是执行几万亿个浮点数乘法操作,那启动时间那点份额就不算什么了。
当然,这与JVM的具体实现有关,像JRockit这种没有解释器的JVM来说,启动速度会比使用了解释器的JVM稍慢一些。
因此,一定要注意只有当目标任务真正开始执行时才应该开始计时,而不是从执行主函数开始就计时。类似的,使用外部程序来统计Java程序的执行时间时,统计结果中也包含了JVM的启动时间,分析统计结果时要多加小心。
当然,还有一些微基准测试是专门用于测量启动时间的。此处不再赘述。
下面的示例代码本意是想测试加法操作的执行效率,但由于加法操作的次数太少,其执行时间远远小于JVM的启动时间,因此测试结果是无效的。
import java.util.Random;
public class AnotherBadMicro {
static Random r = new Random();
static int sum;
public static void main(String args[]) {
long t0 = System.currentTimeMillis();
int s = 0;
for (int i = 0; i < 1000; i++) {
s += r.nextInt();
}
sum = s;
long t1 = System.currentTimeMillis();
System.out.println("Time: " + (t1 - t0) + " ms");
}
}
不同的JVM实现中可能会使用不同的优化策略,因此,在开始实际测试之前,先让代码做做 "热身运动",可以使测试结果更准确。"热身"可以使JVM得到试运行目标代码的反馈信息,执行相关优化,从而使JVM在真正开始测试时可以处于经过优化的稳定的状态。
很多工业级标准的基准测试工具中,例如在本章后面小节中会提到的SPECjvm2008,都内建了对目标任务进行 "热身"的操作。